feat(headless): add headless Drawer primitive#9056
Conversation
A modal bottom-sheet overlay (`@clerk/headless/drawer`) built on the same Floating UI infrastructure as Dialog, with a hand-rolled pointer/transform drag engine: - Drag-to-dismiss with velocity + distance thresholds - Optional snap points (square-root overshoot damping; resets to the default on close) - Virtual-keyboard awareness, nested drawers, detached triggers - A scroll-aware drag gate that never hijacks form controls (select, range, slider thumbs, [data-cl-drawer-no-drag], and text/input/textarea/contenteditable selections) or scrolled inner content - Ships zero CSS: emits raw --cl-drawer-* custom properties and data-cl-* attributes 59 tests (drag, gate branches, snap lifecycle, nested counting, a11y).
🦋 Changeset detectedLatest commit: e985376 The changes in this PR will be included in the next version bump. This PR includes changesets to release 0 packagesWhen changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR adds a new headless Drawer primitive with drag, snap, nesting, input repositioning, subcomponents, exports, docs, tests, and build/docs registry wiring. ChangesDrawer Primitive
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Comment |
@clerk/astro
@clerk/backend
@clerk/chrome-extension
@clerk/clerk-js
@clerk/electron
@clerk/electron-passkeys
@clerk/eslint-plugin
@clerk/expo
@clerk/expo-passkeys
@clerk/express
@clerk/fastify
@clerk/hono
@clerk/localizations
@clerk/nextjs
@clerk/nuxt
@clerk/react
@clerk/react-router
@clerk/shared
@clerk/tanstack-react-start
@clerk/testing
@clerk/ui
@clerk/upgrade
@clerk/vue
commit: |
There was a problem hiding this comment.
Actionable comments posted: 9
🧹 Nitpick comments (1)
packages/headless/src/primitives/drawer/drawer-handle.ts (1)
3-17: 📐 Maintainability & Code Quality | 🔵 TrivialPlease route this new handle API through a Docs review.
DrawerHandle/createDrawerHandle()look reference-facing, and this JSDoc will likely flow into generated/object/**docs. A Docs-team pass here would help confirm the detached-trigger contract is documented the way we want before release. As per path instructions, "Clerk Docs now generates/object/**reference documentation from JSDoc comments in theclerk/javascriptsource code" and "If JSDoc changes may affect generated Clerk Docs content, leave a review note reminding the contributor that the Docs team may need to review the change."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/headless/src/primitives/drawer/drawer-handle.ts` around lines 3 - 17, This new reference-facing DrawerHandle/createDrawerHandle API and its JSDoc should be routed through Docs review because it will surface in generated /object/** documentation. Add a review note or equivalent reminder near the DrawerHandle interface and createDrawerHandle() implementation indicating that the Docs team should confirm the detached-trigger contract wording before release.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.changeset/wise-drawers-appear.md:
- Around line 1-2: The changeset is empty even though this PR adds the new
public primitive and export subpath for `@clerk/headless/drawer`, so it will not
generate a release entry. Update the changeset to include a minor version bump
for `@clerk/headless` and add a short summary describing the new drawer
primitive/export, or confirm that release tracking is being handled separately
before leaving it empty.
In `@packages/headless/package.json`:
- Around line 44-47: The conditional export entry for the drawer subpath in
package.json has "import" before "types", which should be reordered to put
"types" first. Update the entire exports map in the package.json exports object
so every conditional subpath follows the same order, using the drawer entry as
the pattern to fix and keeping the rest of the export targets unchanged.
In `@packages/headless/src/primitives/drawer/drawer-popup.tsx`:
- Around line 65-76: The popup’s `ownProps` in `drawer-popup.tsx` applies a
blanket `touchAction: 'none'`, which blocks native scrolling inside the entire
drawer. Update `DrawerPopup` so `touch-action` is only applied to the actual
drag handle or toggled during an active drag, and keep the rest of the popup
scrollable on touch devices.
In `@packages/headless/src/primitives/drawer/drawer-root.tsx`:
- Around line 87-109: The Drawer.Root state logic is mixing two sources of
truth: when a `handle` is present, `handleOpen` overrides `internalOpen`, so
`props.open`/`props.defaultOpen` no longer control the rendered state and
`onOpenChange` can be skipped. Update `DrawerRoot` to either keep `handle`
synchronized with the controllable state in `useControllableState`/`setOpen`,
including propagating changes through `onOpenChange`, or explicitly disallow
passing both `handle` and `open`/`defaultOpen` together. Use the `handleOpen`
and `setOpen` paths in `drawer-root.tsx` as the place to reconcile the contract.
In `@packages/headless/src/primitives/drawer/drawer.test.tsx`:
- Line 31: The type annotations in DrawerFixture, ControlsFixture, and
AncestorScrollFixture currently reference React.ComponentProps without importing
React, so the test file will not type-check. Update these signatures to use
ComponentProps imported from react, or alternatively add the missing React
import, and make sure all three fixture definitions are adjusted consistently.
In `@packages/headless/src/primitives/drawer/helpers.ts`:
- Around line 76-83: Guard the pointer-capture call in safeCapture before
invoking el[method](id), because setPointerCapture/releasePointerCapture may be
missing and cause a TypeError that bypasses the current NotFoundError handling.
Update safeCapture in helpers.ts to check that the selected method exists on the
Element (or otherwise safely no-op) before calling it, while keeping the
existing DOMException NotFoundError suppression for supported browsers.
In `@packages/headless/src/primitives/drawer/use-drawer-drag.ts`:
- Around line 249-254: The release path in use-drawer-drag is discarding
velocity direction by converting the sampled value with Math.abs, which causes
snap and dismiss logic to treat upward and downward flicks the same. Keep the
velocity signed when computing the release value and pass that signed velocity
through snap.onRelease and the downstream logic around the release checks so
direction-sensitive behavior stays correct. Update the SnapReleaseArgs contract
in use-snap-points to document the signed velocity consistently and ensure any
release handling that compares dist and v uses the signed value rather than only
magnitude.
In `@packages/headless/src/primitives/drawer/use-snap-points.ts`:
- Around line 55-64: Normalize snap-point bounds in useSnapPoints: treat an
empty snapPoints array as having no valid indices, and clamp both
opts.defaultActiveSnapPoint and opts.activeSnapPoint into the valid 0..lastIndex
range before passing them to useControllableState. Update the
lastIndex/defaultIndex logic so [] does not produce -1, and ensure restOffset
and snapTo always receive a safe index/offset. Apply the same sanitization in
the related snap-point handling around snapTo/restOffset so external values
cannot produce NaNpx.
---
Nitpick comments:
In `@packages/headless/src/primitives/drawer/drawer-handle.ts`:
- Around line 3-17: This new reference-facing DrawerHandle/createDrawerHandle
API and its JSDoc should be routed through Docs review because it will surface
in generated /object/** documentation. Add a review note or equivalent reminder
near the DrawerHandle interface and createDrawerHandle() implementation
indicating that the Docs team should confirm the detached-trigger contract
wording before release.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Repository UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: d9c9de39-a770-45d5-9170-df08a70ca8a5
📒 Files selected for processing (29)
.changeset/wise-drawers-appear.mdpackages/headless/package.jsonpackages/headless/src/primitives/drawer/README.mdpackages/headless/src/primitives/drawer/constants.tspackages/headless/src/primitives/drawer/css-vars.tspackages/headless/src/primitives/drawer/drawer-backdrop.tsxpackages/headless/src/primitives/drawer/drawer-close.tsxpackages/headless/src/primitives/drawer/drawer-context.tspackages/headless/src/primitives/drawer/drawer-description.tsxpackages/headless/src/primitives/drawer/drawer-handle-grip.tsxpackages/headless/src/primitives/drawer/drawer-handle.tspackages/headless/src/primitives/drawer/drawer-popup.tsxpackages/headless/src/primitives/drawer/drawer-portal.tsxpackages/headless/src/primitives/drawer/drawer-root.tsxpackages/headless/src/primitives/drawer/drawer-title.tsxpackages/headless/src/primitives/drawer/drawer-trigger.tsxpackages/headless/src/primitives/drawer/drawer-viewport.tsxpackages/headless/src/primitives/drawer/drawer.test.tsxpackages/headless/src/primitives/drawer/helpers.tspackages/headless/src/primitives/drawer/index.tspackages/headless/src/primitives/drawer/parts.tspackages/headless/src/primitives/drawer/use-drawer-drag.tspackages/headless/src/primitives/drawer/use-reposition-inputs.tspackages/headless/src/primitives/drawer/use-snap-points.tspackages/headless/vite.config.tspackages/swingset/src/components/DocsViewer.tsxpackages/swingset/src/lib/registry.tspackages/swingset/src/stories/drawer.mdxpackages/swingset/src/stories/drawer.stories.tsx
useSnapPoints computed the resting offset from window.innerHeight during render, crashing server renders of a Drawer with snapPoints (ReferenceError: window is not defined). Guard the read so it returns 0 until the client can measure; the popup mount-effect writes the real offset. Adds a node-environment renderToString regression test.
…signals Settle the parent's live scale toward the rest it is heading to on release (scaled-back if the child stays open, full on dismiss), matching the open-count drop so the styled scale animates one direction with no backward flicker. Restores the childOpen signal on onNestedRelease; snap release now returns it. Reset --cl-drawer-nested-drag-progress to 0 when a nested child opens so a prior dismiss does not leave the next parent un-scaled. Drop the unused --cl-drawer-frontmost-height var (vaul uses a fixed displacement, not a height-based peek) and document the full nested styled-layer recipe.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/headless/src/primitives/drawer/drawer.ssr.test.tsx (1)
15-27: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueConsider asserting on rendered output, not just "no throw".
Both tests only check
.not.toThrow(). Capturing the returned HTML string and asserting it contains the expected content (e.g.,"hi") would make the tests stronger regression guards, catching cases where SSR silently swallows an error or renders empty markup.♻️ Optional strengthening
- it('renders a plain drawer on the server', () => { - expect(() => - renderToString( + it('renders a plain drawer on the server', () => { + let html = ''; + expect(() => { + html = renderToString( <Drawer.Root open> <Drawer.Portal> <Drawer.Viewport> <Drawer.Popup>hi</Drawer.Popup> </Drawer.Viewport> </Drawer.Portal> </Drawer.Root>, - ), - ).not.toThrow(); + ); + }).not.toThrow(); + expect(html).toContain('hi'); });Also applies to: 31-46
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/headless/src/primitives/drawer/drawer.ssr.test.tsx` around lines 15 - 27, The SSR tests in Drawer should do more than assert renderToString does not throw; update the relevant test cases in Drawer.Root/Drawer.Portal/Drawer.Viewport/Drawer.Popup to capture the returned HTML string and assert it includes the expected rendered content such as “hi”. Keep the existing no-throw check if desired, but strengthen the test by verifying output from renderToString so the assertions fail when markup is missing or empty.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/headless/src/primitives/drawer/drawer.ssr.test.tsx`:
- Around line 15-27: The SSR tests in Drawer should do more than assert
renderToString does not throw; update the relevant test cases in
Drawer.Root/Drawer.Portal/Drawer.Viewport/Drawer.Popup to capture the returned
HTML string and assert it includes the expected rendered content such as “hi”.
Keep the existing no-throw check if desired, but strengthen the test by
verifying output from renderToString so the assertions fail when markup is
missing or empty.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository YAML (base), Repository UI (inherited)
Review profile: CHILL
Plan: Pro Plus
Run ID: 8a2445bb-0d1b-4b4b-8d71-9793d06c84ee
📒 Files selected for processing (2)
packages/headless/src/primitives/drawer/drawer.ssr.test.tsxpackages/headless/src/primitives/drawer/use-snap-points.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/headless/src/primitives/drawer/use-snap-points.ts
Address CodeRabbit review on PR #9056: - use-drawer-drag/use-snap-points: keep release velocity signed so a fast upward flick that ends net-downward settles upward (or expands a snap point) instead of being read as a downward dismiss. - use-snap-points: treat an empty `snapPoints` array as "no snap points" and clamp externally supplied indices, preventing `-1`/`NaNpx` offsets. - drawer-popup: don't surface snap state for an empty `snapPoints` array. - helpers: guard `safeCapture` against environments missing set/releasePointerCapture (an absent method threw TypeError past the NotFoundError catch). - use-reposition-inputs: clear the popup lift when the keyboard closes so the sheet doesn't stay raised until unmount.
…ched handle Following Base UI's dialog-handle model: the component's controllable open state is the one source of truth, and every transition — including imperative handle calls — flows through a single `setOpen` that fires `onOpenChange`. Previously, passing a `handle` made the handle store the source of truth, so `open`/`defaultOpen` were ignored and handle-driven transitions (detached trigger clicks, `handle.open()`) bypassed `onOpenChange`. Now `Drawer.Root` owns open state via `useControllableState`, and the handle is a bridge: `connect()` routes its imperative calls back through the root's `setOpen`, `isOpen` reflects the root, and the root `emit()`s on change so detached triggers re-read. An open requested before the root mounts is adopted on connect without clobbering `defaultOpen`. Fixes the CodeRabbit contract finding on PR #9056.
…touchend leak Reposition: measure the popup's natural height by clearing our own cap before reading getBoundingClientRect, so the height cap is idempotent. Previously it read back the value it had just set, so the cap flipped off on the next visualViewport resize and back on the one after, thrashing the sheet height while the keyboard stayed open. Drag: track the iOS touchend fallback listener and remove it on release and on unmount (and drop a stale one before adding a new one), so it can't outlive its gesture or accumulate on window across gestures.
Description
Adds a headless Drawer primitive to
@clerk/headless(@clerk/headless/drawer): a modal bottom-sheet overlay with drag-to-dismiss, optional snap points, virtual-keyboard awareness, nested drawers, and detached triggers. It reuses the same Floating UI infrastructure asDialog(portal, focus trap, scroll lock, dismiss,FloatingTreenesting, enter/exit transitions) with a hand-rolled pointer/transform drag engine layered on top.Docs: https://swingset-git-feat-headless-drawer.clerkstage.dev/primitives/drawer
Summary by CodeRabbit
./drawerpublic entry point, plus a styling contract via registered CSS variables anddata-cl-*state attributes.